iT邦幫忙

2021 iThome 鐵人賽

DAY 25
0
Software Development

【今年還是不夠錢買psQQ,不如我們用PyQt自己寫一個】系列 第 25

【沒錢買ps,PyQt自己寫】Day 25 - project / 自己做一個影片播放器 DIY video player (結合 PyQt + OpenCV)

  • 分享至 

  • xImage
  •  

看完這篇文章你會得到的成果圖

此篇文章的範例程式碼 github

https://github.com/howarder3/ironman2021_PyQt5_photoshop/tree/main/day25_video_player_project

之前內容的重點複習 (前情提要)

我們接下來的討論,會基於讀者已經先讀過我 day5 文章 的架構下去進行程式設計
如果還不清楚我程式設計的邏輯 (UI.py、controller.py、start.py 分別在幹麻)
建議先閱讀 day5 文章後再來閱讀此文。

https://www.wongwonggoods.com/python/pyqt5-5/

設計我們的 UI

我們在裡面加入了一些我們需要的元素:

  • self.button_stop:停止鍵

  • self.button_play:播放鍵

  • self.button_pause:暫停鍵

  • self.button_openfile:開啟檔案鍵

  • self.label_videoframe:顯示畫面

  • self.label_framecnt:顯示目前 frame 數/ 全部 frame 數

  • self.label_filepath:顯示檔案路徑

一些 UI 設計小細節

  1. 與之前設計圖片不同的是,我們拿掉了可以捲動的滑條,
    我希望能夠強制更改比例以符合視窗 (方便一個視窗就能瀏覽)。

  2. 我設計的顯示框為 800x450,等於 16:9,
    符合目前最常見的影片比例 1920x1080、1280x720

轉換 day25.ui -> UI.py

pyuic5 -x day25.ui -o UI.py

執行看看 UI.py 畫面是否如同我們想像

一樣,這程式只有介面 (視覺上的呈現),沒有任何互動功能

  • 看看我們製作出來的介面
python UI.py

設計我們的 controller

設計使用狀態 (state)

我們要設計一個播放器,我們必須要想好播放器的架構可能會有哪幾種 ”state (狀態)“,
我們可以簡單地想一下:

  • 按下 play 後,進行 play 狀態,影片播放
  • 按下 pause 後,進行 pause 狀態,影片暫停
  • 按下 stop 後,進行 stop 狀態,影片回到第一格

而剛開始載入影片時,我們選擇的狀態是 pause,因為暫停狀態才可以任意變更 frame 值 (後續的應用),
而停止狀態永遠都會回到第一格。

以上大概就是我們設計的 state。

設計 video_controller.py

正如同我們前面的文章,這次我們把 img_controller 修改為 video_controller,
並加入類似的功能。

from PyQt5 import QtCore 
from PyQt5.QtGui import QImage, QPixmap
from PyQt5.QtCore import QTimer 

from opencv_engine import opencv_engine

# videoplayer_state_dict = {
#  "stop":0,   
#  "play":1,
#  "pause":2     
# }

class video_controller(object):
    def __init__(self, video_path, ui):
        self.video_path = video_path
        self.ui = ui
        self.qpixmap_fix_width = 800 # 16x9 = 1920x1080 = 1280x720 = 800x450
        self.qpixmap_fix_height = 450
        self.current_frame_no = 0
        self.videoplayer_state = "stop"
        self.init_video_info()
        self.set_video_player()

    def init_video_info(self):
        videoinfo = opencv_engine.getvideoinfo(self.video_path)
        self.vc = videoinfo["vc"] 
        self.video_fps = videoinfo["fps"] 
        self.video_total_frame_count = videoinfo["frame_count"] 
        self.video_width = videoinfo["width"]
        self.video_height = videoinfo["height"] 

    def set_video_player(self):
        self.timer=QTimer() # init QTimer
        self.timer.timeout.connect(self.timer_timeout_job) # when timeout, do run one
        # self.timer.start(1000//self.video_fps) # start Timer, here we set '1000ms//Nfps' while timeout one time
        self.timer.start(1) # but if CPU can not decode as fast as fps, we set 1 (need decode time)

    def __get_frame_from_frame_no(self, frame_no):
        self.vc.set(1, frame_no)
        ret, frame = self.vc.read()
        self.ui.label_framecnt.setText(f"frame number: {frame_no}/{self.video_total_frame_count}")
        return frame

    def __update_label_frame(self, frame):       
        bytesPerline = 3 * self.video_width
        qimg = QImage(frame, self.video_width, self.video_height, bytesPerline, QImage.Format_RGB888).rgbSwapped()
        self.qpixmap = QPixmap.fromImage(qimg)

        if self.qpixmap.width()/16 >= self.qpixmap.height()/9: # like 1600/16 > 90/9, height is shorter, align width
            self.qpixmap = self.qpixmap.scaledToWidth(self.qpixmap_fix_width)
        else: # like 1600/16 < 9000/9, width is shorter, align height
            self.qpixmap = self.qpixmap.scaledToHeight(self.qpixmap_fix_height)
        self.ui.label_videoframe.setPixmap(self.qpixmap)
        # self.ui.label_videoframe.setAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop) # up and left
        self.ui.label_videoframe.setAlignment(QtCore.Qt.AlignHCenter | QtCore.Qt.AlignVCenter) # Center

    def play(self):
        self.videoplayer_state = "play"

    def stop(self):
        self.videoplayer_state = "stop"

    def pause(self):
        self.videoplayer_state = "pause"

    def timer_timeout_job(self):
        frame = self.__get_frame_from_frame_no(self.current_frame_no)
        self.__update_label_frame(frame)

        if (self.videoplayer_state == "play"):
            self.current_frame_no += 1

        if (self.videoplayer_state == "stop"):
            self.current_frame_no = 0

        if (self.videoplayer_state == "pause"):
            self.current_frame_no = self.current_frame_no

我們開始來慢慢解釋這些東西。

播放邏輯

這邊我們使用 Qtimer,原因很簡單,每支影片都有他自己的 fps,
我們透過計算可以得到「我們應該每多少毫秒,就該換下一個 frame 顯示」。

我們用 frame number 來管理現在要顯示哪一個畫面,
而控制 frame number 的就是我們目前 state 的狀態,以每一個 QTimer timeout 的頻率更新。

def set_video_player(self):
    self.timer=QTimer() # init QTimer
    self.timer.timeout.connect(self.timer_timeout_job) # when timeout, do run one
    # self.timer.start(1000//self.video_fps) # start Timer, here we set '1000ms//Nfps' while timeout one time
    self.timer.start(1) # but if CPU can not decode as fast as fps, we set 1 (need decode time)
	
def timer_timeout_job(self):
    frame = self.__get_frame_from_frame_no(self.current_frame_no)
    self.__update_label_frame(frame)

    if (self.videoplayer_state == "play"):
        self.current_frame_no += 1

    if (self.videoplayer_state == "stop"):
        self.current_frame_no = 0

    if (self.videoplayer_state == "pause"):
        self.current_frame_no = self.current_frame_no

但這邊我們在執行後才發現我們雖然邏輯正確,但想得太美了
OpenCV 在 decode 所需要花的時間大於我們想要控制的顯示時間,
(簡單來說,decode 太久,導致沒辦法在依照我們想要的 fps 播放)
所以我先暫時改成 self.timer.start(1),讓我們只休息 1ms,
但畢竟 QT 是以 multithread 在進行操作,
這段優化的空間可能要改以 multiprocess 進行才能夠讓我們影片順暢的播放 (這個比較不是此系列重點,有空我們再來實作)

播放鍵相關 (play, stop, pause)

def play(self):
    self.videoplayer_state = "play"

def stop(self):
    self.videoplayer_state = "stop"

def pause(self):
    self.videoplayer_state = "pause"

這邊我使用的邏輯,就是讓按鍵會直接更改到 state 的狀態,
而 state 會去控制現在視窗要顯示的 frame

取得 frame 的圖片,並更新至 UI 介面上

從上面應該可以理解一些小細節,我們用 frame number 來管理我們要顯示的 frame,
而我們透過 frame number 取得 frame 影像的機制,我們寫在 __get_frame_from_frame_no() 當中。

而取得介面後,並更新於 UI 介面的機制,我們寫在 __update_label_frame() 當中。

這邊也有個小細節,我們會自動以 16:9 為基準去看讀入影片的比例,

  • 如果很明顯是較寬 例如 160:9,我們就以寬 (160) 為基準去縮放影片大小
  • 如果很明顯是較高 例如 16:90,我們就以高 (90) 為基準去縮放影片大小
def __get_frame_from_frame_no(self, frame_no):
    self.vc.set(1, frame_no)
    ret, frame = self.vc.read()
    self.ui.label_framecnt.setText(f"frame number: {frame_no}/{self.video_total_frame_count}")
    return frame

def __update_label_frame(self, frame):       
    bytesPerline = 3 * self.video_width
    qimg = QImage(frame, self.video_width, self.video_height, bytesPerline, QImage.Format_RGB888).rgbSwapped()
    self.qpixmap = QPixmap.fromImage(qimg)

    if self.qpixmap.width()/16 >= self.qpixmap.height()/9: # like 1600/16 > 90/9, height is shorter, align width
        self.qpixmap = self.qpixmap.scaledToWidth(self.qpixmap_fix_width)
    else: # like 1600/16 < 9000/9, width is shorter, align height
        self.qpixmap = self.qpixmap.scaledToHeight(self.qpixmap_fix_height)
    self.ui.label_videoframe.setPixmap(self.qpixmap)
    # self.ui.label_videoframe.setAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop) # up and left
    self.ui.label_videoframe.setAlignment(QtCore.Qt.AlignHCenter | QtCore.Qt.AlignVCenter) # Center

設計 opencv_engine.py

讀取影片的資訊

相信有閱讀之前文章的讀者應該都不陌生,
而這邊我們要透過 opencv 協助我們完成影片的讀取,並分析一些資訊。

程式被呼叫的地方在 video_controller 的 init_video_info,
我們把所有必要的影片資訊封裝成一個 dict 回傳。

def init_video_info(self):
    videoinfo = opencv_engine.getvideoinfo(self.video_path)
    self.vc = videoinfo["vc"] 
    self.video_fps = videoinfo["fps"] 
    self.video_total_frame_count = videoinfo["frame_count"] 
    self.video_width = videoinfo["width"]
    self.video_height = videoinfo["height"] 

所以我們在 opencv_engine.py 實作一個新的方法。

@staticmethod
def getvideoinfo(video_path): 
    # https://docs.opencv.org/4.5.3/dc/d3d/videoio_8hpp.html
    videoinfo = {}
    vc = cv2.VideoCapture(video_path)
    videoinfo["vc"] = vc
    videoinfo["fps"] = vc.get(cv2.CAP_PROP_FPS)
    videoinfo["frame_count"] = int(vc.get(cv2.CAP_PROP_FRAME_COUNT))
    videoinfo["width"] = int(vc.get(cv2.CAP_PROP_FRAME_WIDTH))
    videoinfo["height"] = int(vc.get(cv2.CAP_PROP_FRAME_HEIGHT))
    return videoinfo

把一些我們感興趣的資訊都存進 videoinfo 裡面,並回傳。

設計 controller.py

設計按鍵與功能的連結、開啟檔案

def setup_control(self):
    self.ui.button_openfile.clicked.connect(self.open_file)

def open_file(self):
    filename, filetype = QFileDialog.getOpenFileName(self, "Open file Window", "./", "Video Files(*.mp4 *.avi)") # start path        
    self.video_path = filename
    self.video_controller = video_controller(video_path=self.video_path,
                                             ui=self.ui)
    self.ui.label_filepath.setText(f"video path: {self.video_path}")
    self.ui.button_play.clicked.connect(self.video_controller.play) # connect to function()
    self.ui.button_stop.clicked.connect(self.video_controller.stop)
    self.ui.button_pause.clicked.connect(self.video_controller.pause)

我們先讓開啟檔案按鍵的功能連結起來,
開檔成功之後,才綁定按鍵的功能,這些功能定義在 video_controller 中。
我們預期所有的按鍵行為應該是在「開檔後」才會執行 (例如:沒讀取影片,沒必要讓「播放」有功能。)

測試結果

我們目前有一個很 lag 的 video player,
原因可能是因為 decode 速度不夠快,可能可以透過 multiprocess 優化。

後續:後來有找到原因,為 vc.set 反覆執行會吃掉大量程式效率,之後文章會再分享該如何修正

Reference


★ 本文也同步發於我的個人網站(會有內容目錄與顯示各個小節,閱讀起來更流暢):【PyQt5】Day 25 project / 自己做一個影片播放器 DIY video player (結合 PyQt + OpenCV)


上一篇
【沒錢買ps,PyQt自己寫】Day 24 - project / 偵測滑鼠目前指示顏色的小工具 (滴管工具), 利用 QCursor 偵測滑鼠, QApplication 取得截圖
下一篇
【沒錢買ps,PyQt自己寫】Day 26 - project / 替我們影片播放器增加一個顯示進度的滑條 video player add slider (與昨日 bottleneck 處理細節)
系列文
【今年還是不夠錢買psQQ,不如我們用PyQt自己寫一個】30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言